#!/usr/bin/python
# -*- coding: utf-8 -*-
# vim: ai ts=4 sts=4 et sw=4
#
# Courier filter that appends signatures to e-mail messages
# Author: Aleksander Adamowski <aleksander.adamowski at olo.org.pl>
# Based-on: Pawel Sawicki's isoCourierFilter (Pawel J. Sawicki <p-j-s@p-j-s.name>)
# s/-//g
# $Id: sigappender.py 70 2008-06-12 22:58:36Z olo $
#
############################################################################
#                                                                          #
#   This program is free software; you can redistribute it and/or modify   #
#   it under the terms of the GNU General Public License as published by   #
#   the Free Software Foundation; either version 2 of the License, or      #
#   (at your option) any later version.                                    #
#                                                                          #
############################################################################

import courier.config
import courier.control
import courier.xfilter
import ldap
import re
import codecs
import time
import sys
import os
import os.path
import stat
import email
from email.MIMEText import MIMEText
import pickle
import thread
import gc

reHTMLBodyClose                     = re.compile('(</BODY>|</BODY>.*</HTML>)', re.I | re.S)
reNonAlnumChars                     = re.compile('[^a-zA-Z0-9]')
reTextType                          = re.compile('^text/', re.I)
# Cannot place in config file as the constant is defined in external module:
ldapSearchScope     = ldap.SCOPE_SUBTREE                    # search scope
ldapMutex           = thread.allocate_lock()

def initFilter():
    global exemptRecipientDomains
    global exemptSenderDomains
    global ldapServerURI
    global ldapBindDN
    global ldapBindPassword
    global reExemptRecipientDomain
    global reExemptSenderDomain
    global ldapConnection
    courier.config.applyModuleConfig('courier-pythonfilters-ldap', globals())
    courier.config.applyModuleConfig('sigappender.py', globals())
    reExemptRecipientDomain = re.compile("[^@]+@("+("|".join(exemptRecipientDomains))+")", re.I)
    reExemptSenderDomain = re.compile("[^@]+@("+("|".join(exemptSenderDomains))+")", re.I)
    ldapMutex.acquire()
    try:
      # connect to ldap server
      ldapConnection      = ldap.initialize(ldapServerURI)
      # bind synchronously
      ldapConnection.simple_bind_s(ldapBindDN, ldapBindPassword)
    except ldap.LDAPError, error_message:
        debug_log('LDAP operation error during init: %s' % error_message)
        ldapMutex.release()
    if ldapMutex.locked():
      ldapMutex.release()
    # say hello
    sys.stderr.write('Initialized the "sigappender" python filter.\n')

def doFilter(bodyFile, controlFileList):
#    try:
        global ldapConnection
        global ldapServerURI
        global ldapBaseDN
        global ldapAttributes
        global attribPrefixes
        global ldapFilter
        global ldapMailAttributes
        global ldapMailFilter
        global templateDir
        global acceptableCharsets
        global reExemptRecipientDomain
        global reExemptSenderDomain
        global reHTMLBodyClose
        global reNonAlnumChars
        global reTextType
        global ldapSearchScope


        """
        Get the message from the queue, but if it has already been processed,
        stop processing and leave it alone:
        """
        try:
            msgfilter = courier.xfilter.XFilter('sigappendfilter', bodyFile,
                controlFileList)
        except courier.xfilter.LoopError, e:
            debug_log('Message already filtered')
            # LoopError indicates that we've already filtered this message.
            return ''

        msg = msgfilter.getMessage()

        # get the authenticated user:
        authUser = determine_auth_user(controlFileList, bodyFile, msg)
        if authUser is None:
            debug_log('No authentication data. Terminating.')
            return ''

        """
        Check if all recipients' adresses belong to an exempt recipient domain.
        If at least one non-exempt address is present in the recipient table -
        append the signature:
        """
        isNonExemptRecipientPresent = False

        # get the recipients table
        recipients = courier.control.getRecipients(controlFileList)

        # find any non-exempt adresses
        for recipient in recipients:
            if not reExemptRecipientDomain.match(recipient.lower()):
                #debug_log('non exempt recipient found: ' + recipient)
                isNonExemptRecipientPresent = True


        """
        If all recipients are in exempt domains, don't append signature and
        stop processing:
        """
        if not isNonExemptRecipientPresent:
            debug_log('no non-exempt recipients, exiting.')
            return ''

        # prepare local ldap filter
        localLdapFilter = ldapFilter % { 'uid': authUser }

        (ldapResultType, ldapResultData) = threadsafe_ldap_search(ldapBaseDN, ldapSearchScope, localLdapFilter, ldapAttributes)

        if len(ldapResultData) != 1 or not ldapResultType == ldap.RES_SEARCH_RESULT:
            # user hasn't been found in the LDAP directory
            debug_log('no LDAP entry or ambiguous search result for %s.' % localLdapFilter)
            debug_log(pickle.dumps(ldapResultData))
            return '' 

        #debug_log('got sender data from LDAP: ' + pickle.dumps(ldapResultData))
        ldapEntryAttrs = ldapResultData[0][1]

        """
        Check if the sender isn't exempted from signature appending.
        If he is, stop processing:
        """
        # get the sender
        emailSender = courier.control.getSender(controlFileList).lower()
        if 'mail' in ldapEntryAttrs:
            emailSender = ldapEntryAttrs['mail'][0].lower()
        else:
            debug_log('No "mail" attribute for %s!' % authUser)

        if reExemptSenderDomain.match(emailSender):
            debug_log('sender is exempt, exiting.')
            return ''


        if 'altkomSigTemplate' in ldapEntryAttrs:
            altkomSigTemplate = ldapEntryAttrs['altkomSigTemplate'][0]
            altkomSigTemplate = re.sub(reNonAlnumChars,'', altkomSigTemplate)
            debug_log('LDAP entry ' + localLdapFilter + ' has a template: ' + altkomSigTemplate)
        else:
            debug_log('no altkomSigTemplate found for %s, exiting.' % localLdapFilter)
            return '' 

        """
        If any recipient is listen in LDAP as exempt from receiving signatures,
        don't append a signature at all and stop processing:
        """
        if 'altkomSigExceptionAddresses' in ldapEntryAttrs:
            altkomSigExceptionAddresses = ldapEntryAttrs['altkomSigExceptionAddresses']
            for altkomSigExceptionAddr in altkomSigExceptionAddresses:
                for recipient in recipients:
                    if re.match(altkomSigExceptionAddr, recipient.lower()):
                        debug_log('Exception address (%s) matched: %s, not appending a signature.' % (recipient, altkomSigExceptionAddr))
                        return ''
                        

        templateLdapAttrs = {}
        for ldapAttr in ldapAttributes:
            if ldapAttr in ldapEntryAttrs:
                #debug_log('Adding LDAP attr to dict: ' + ldapAttr + ' : ' + ldapEntryAttrs[ldapAttr][0])
                if ldapAttr in attribPrefixes:
                    templateLdapAttrs[ldapAttr] =  unicode(attribPrefixes[ldapAttr] + ldapEntryAttrs[ldapAttr][0], 'utf-8')
                else:
                    templateLdapAttrs[ldapAttr] = unicode(ldapEntryAttrs[ldapAttr][0], 'utf-8')
                    
            else:
                # Wszystkie klucze uzywane przez szablony musza byc zdefiniowane, wiec ustawiamy na pusty ciag:
                templateLdapAttrs[ldapAttr] = ''

        # get the message

        """
        Import the message as MIME object, process it
        """
        #appendedPlain = False
        #appendedHTML = False
        appended = { 'plain' : False, 'html' : False }

        #debug_log('read the message ' + bodyFile)
        #debug_log('got message body: ' + msg.as_string())
        #globalCharset = msg.get_charset()
        globalCharset = msg.get_param('charset')
        if globalCharset is None or not globalCharset.lower() in acceptableCharsets:
            globalCharset = 'utf-8'
            msg.set_param('charset', globalCharset)
            # Works properly only on leaves?
            #msg.set_charset(globalCharset)
            debug_log('setting global charset to ' + str(globalCharset))
        else:
            debug_log('global charset: ' + str(globalCharset))

        for part in msg.walk():
            debug_log(part.get_content_type())
            #debug_log(pickle.dumps(part.get_params()))
            contentType = str(part.get_content_type()).lower()
            if contentType.startswith('text/'):
                textType = re.sub(reTextType, '', contentType)
                textType = re.sub(reNonAlnumChars,'', textType)
                debug_log('text/ subtype:' + textType)
                if (textType in appended and (not appended[textType])):
                    charset = str(part.get_param('charset'))
                    if charset is None or not charset.lower() in acceptableCharsets:
                        debug_log('setting charset for %s mimepart to %s' % (textType, globalCharset))
                        charset = globalCharset
                        part.set_param('charset', globalCharset)
                        part.set_charset(globalCharset)
                    else:
                        debug_log(textType + ' mimepart charset: ' + str(charset))
                    charset = str(part.get_param('charset'))
                    payload = part.get_payload(decode=True)
                    #debug_log(payload)
                    templateFileName = os.path.join(templateDir, altkomSigTemplate + '.' + textType)
                    if os.access(templateFileName, os.R_OK):
                        if stat.S_ISREG(os.lstat(templateFileName)[0]):
                            debug_log('Loading %s template %s' % (textType, templateFileName))
                            f = codecs.open(templateFileName, 'r', 'utf-8')
                            templateContent = f.read() 
                            f.close()
                            templateContent = templateContent % templateLdapAttrs
                            templateContent = templateContent.encode(charset, 'replace')

                            if textType == 'plain':
                                payload += templateContent
                            elif textType == 'html':
                                if reHTMLBodyClose.search(payload):
                                    debug_log('HTML matches reHTMLBodyClose')
                                    payload = re.sub(reHTMLBodyClose, templateContent + '\n\\1', payload)
                                else:
                                    payload +=  templateContent

                            # In order to transfer-encode the payload:
                            part.set_charset(str(part.get_param('charset')))
                            debug_log("body encoding: " + str(part.get_charset().get_body_encoding()) \
                               + ", charset: " + str(part.get_param('charset')))
                            debug_log('Part charset: in - %s out - %s' % (part.get_charset().input_charset, part.get_charset().output_charset))
                            appended[textType] = True
                            part.set_payload(part.get_charset().body_encode(payload, True))
                            """
                            There's a bug in Python module email in 2.4.3 - it doesn't
                            modify Content-Transfer-Encoding when setting charset for a part
                            that constitutes a non-multipart message.
                            Here's a workaround:
                            """
                            if part.has_key('Content-Transfer-Encoding'):
                                part.replace_header('Content-Transfer-Encoding', part.get_charset().get_body_encoding())
                        else:
                            debug_log('%s template for %s is not a regular file!' % (textType, altkomSigTemplate))
                    else:
                        debug_log('unavailable %s template for %s' % (textType, altkomSigTemplate))
                else:
                    debug_log('part not being modified, content type:' + part.get_content_type())

        try:
          msgfilter.submit()
          debug_log("sigappender.py has successfully filtered the message " + msg.get('message-id'))
        except courier.xfilter.SubmitError, e:
          debug_log('error submitting message: %s' % str(e))
          del msg
          del msgfilter
        del msg
        del msgfilter
        if courier.config.isMinVersion('0.57.1'):
          return ''
        else:
          # Old courier version, submit created a new message.
          # Return 250, no more filters should be run on this copy.
          return '050 OK'

#    except Exception, e:
        # all exceptions are ignored by the python filter, so it is safe to raise them here
#        raise e

def debug_log(message):
    global debug_logging
    if debug_logging:
        sys.stderr.write(__name__ + ': ' + message + '\n')

def threadsafe_ldap_search(ldapBaseDN, ldapSearchScope, localLdapFilter, ldapAttributes):
    global ldapConnection
    global ldapMutex
    global ldapRetries
    global ldapBindDN
    global ldapBindPassword
    global ldapServerURI
    ldapMutex.acquire()
    ldapResultType = ''
    ldapResultData = []
    try:
        for tryNumber in range(ldapRetries):
            if tryNumber == ldapRetries:
                debug_log('Giving up after %d LDAP reconnection attempts.' % ldapRetries)
                return ''
            try:
                # get the signature template id from LDAP:
                ldapResultId = ldapConnection.search(ldapBaseDN, ldapSearchScope, localLdapFilter, ldapAttributes)
                ldapResultType, ldapResultData = ldapConnection.result(ldapResultId)
                break
            except ldap.LDAPError, error_message:
                debug_log('LDAP operation error on try %d out of %d: %s' % (tryNumber + 1, ldapRetries, error_message))
                time.sleep(1)
                if tryNumber != ldapRetries:
                    debug_log('retrying LDAP operation')
                try:
                    del ldapConnection
                    ldapConnection = ldap.initialize(ldapServerURI)
                    ldapConnection.simple_bind_s(ldapBindDN, ldapBindPassword)
                except ldap.LDAPError, error_message:
                    debug_log('LDAP reconnection error on try %d out of %d: %s' % (tryNumber + 1, ldapRetries, error_message))
    finally:
        ldapMutex.release()

    if ldapMutex.locked():
        ldapMutex.release()
        debug_log('Unreleased ldapMutex detected!')
    return (ldapResultType, ldapResultData)

def mail2uid(mailAddress):
    localLdapFilter = ldapMailFilter % { 'mail': mailAddress }
    debug_log('Searching for auth based on 127.0.0.1 sender: ' + localLdapFilter)
    (ldapResultType, ldapResultData) = threadsafe_ldap_search(ldapBaseDN, ldapSearchScope, localLdapFilter, ldapMailAttributes)
    if len(ldapResultData) != 1 or not ldapResultType == ldap.RES_SEARCH_RESULT:
        debug_log('no LDAP entry or ambiguous search result for %s.' % localLdapFilter)
        debug_log(pickle.dumps(ldapResultData))
        del localLdapFilter
        del ldapResultType
        del ldapResultData
        return None
    else:
        mailUID = ldapResultData[0][1]['uid'][0]
        debug_log('Found 127.0.0.1 authUser: ' + mailUID)
        del localLdapFilter
        del ldapResultType
        del ldapResultData
        return mailUID

def determine_auth_user(controlFileList, bodyFile, msg):
    authUser = courier.control.getAuthUser(controlFileList, bodyFile)
    if authUser:
        if msg.has_key('X-Pythonfilters-Auth'):
            msg.replace_header('X-Pythonfilters-Auth', authUser)
        else:
            msg.add_header('X-Pythonfilters-Auth', authUser)
    else:
        if msg.has_key('X-Pythonfilters-Auth'):
            authUser = msg.get('X-Pythonfilters-Auth')
        else:
            if courier.control.getSendersIP(controlFileList) == '127.0.0.1':
                """
                Typical webmail installations don't use SMTP AUTH, but the sender address
                in such cases can usually be trusted and searched on.
                """
                authUser = mail2uid(courier.control.getSender(controlFileList))
            else:
                return None
    return authUser

